How Qutebrowser Modes works

As a browser similar to Vim, qutebrowser also defines various modes internally. For instance, there's a hint mode used for clicking links via the keyboard, and a command mode used for inputting commands.

KeyMode

KeyMode is an enumeration defining all supported modes:

class KeyMode(enum.Enum):

    """Key input modes."""

    normal = enum.auto()  #: Normal mode (no mode was entered)
    hint = enum.auto()  #: Hint mode (showing labels for links)
    command = enum.auto()  #: Command mode (after pressing the colon key)
    yesno = enum.auto()  #: Yes/No prompts
    prompt = enum.auto()  #: Text prompts
    insert = enum.auto()  #: Insert mode (passing through most keys)
    passthrough = enum.auto()  #: Passthrough mode (passing through all keys)
    caret = enum.auto()  #: Caret mode (moving cursor with keys)
    set_mark = enum.auto()
    jump_mark = enum.auto()
    record_macro = enum.auto()
    run_macro = enum.auto()
    # 'register' is a bit of an oddball here: It's not really a "real" mode,
    # but it's used in the config for common bindings for
    # set_mark/jump_mark/record_macro/run_macro.
    register = enum.auto()

ModeManager

The ModeManager class is used to manage the modes of qutebrowser. For detailed information about ModeManager, you can click the link to browse within the notes.

The ModeManager corresponds one-to-one with a MainWindow instance. Its working principle, in brief, involves the following steps:

  1. Receiving keyboard events: When a user presses a key, Qt emits a keyboard event. In qutebrowser, the EventFilter receives this event and forwards it to the handle_event method of the currently active (window's) ModeManager.

  2. Look up the table to find the corresponding mode handler: Depending on the type of keyboard event (KeyPress, KeyRelease, ShortcutOverride), it calls different methods of the ModeManager to handle it: _handle_keypress, _handle_keyrelease, _handle_keypress. In _handle_keypress, it retrieves the current mode and its parser and then calls the parser's handle method to deal with the event.

Each mode has a corresponding parser declared in the init global method of modeman.py.

keyparsers: ParserDictType = {
    usertypes.KeyMode.normal:
        modeparsers.NormalKeyParser(
            win_id=win_id,
            commandrunner=commandrunner,
            parent=modeman),
	//...
	
    usertypes.KeyMode.insert:
        modeparsers.CommandKeyParser(
            mode=usertypes.KeyMode.insert,
            win_id=win_id,
            commandrunner=commandrunner,
            parent=modeman,
            passthrough=True,
            do_log=log_sensitive_keys,
            supports_count=False),
}

for mode, parser in keyparsers.items():
    modeman.register(mode, parser)

Within it, NormalKeyParser, CommandKeyParser, and a series of other Parsers are where the specific logic for each mode is implemented.

BaseKeyParser

BaseKeyParser is the base class for various mode KeyParsers. It's a parser for vim-like key sequences and shortcuts.

BaseKeyParser._read_config

Each mode pre-defines a keymap, declared in the Config file. The BaseKeyParser._read_config method is called by the constructor and is responsible for loading the keymap for the corresponding mode:

def _read_config(self) -> None:
    """Read the configuration."""
    self.bindings = BindingTrie()
    # read from config
    config_bindings = config.key_instance.get_bindings_for(self._mode.name)

    for key, cmd in config_bindings.items():
        assert cmd
        self.bindings[key] = cmd

Taking the default configuration qutebrowser/config/configdata.yml as an example, here's a part of the keymap:

bindings.default:
  no_autoconfig: true
  default:
    normal:
      <Escape>: clear-keychain ;; search ;; fullscreen --leave
      o: cmd-set-text -s :open
      go: cmd-set-text :open {url:pretty}
      O: cmd-set-text -s :open -t
      gO: cmd-set-text :open -t -r {url:pretty}
      xo: cmd-set-text -s :open -b
      xO: cmd-set-text :open -b -r {url:pretty}
      wo: cmd-set-text -s :open -w
      wO: cmd-set-text :open -w {url:pretty}
      /: cmd-set-text /
      ?: cmd-set-text ?
      ":": "cmd-set-text :"
      ga: open -t
      <Ctrl-T>: open -t
      <Ctrl-N>: open -w
      <Ctrl-Shift-N>: open -p

This shows that the part before the colon is the object to match with, and the part after the colon is the command to execute.

BaseKeyParser.handle

This is the most fundamental keyboard event handling function of KeyParser.

def handle(self, e: QKeyEvent, *,
            dry_run: bool = False) -> QKeySequence.SequenceMatch:
    """Handle a new keypress.
    """
	# get sequence combine with previous and this event
    try:
        sequence = self._sequence.append_event(e)
    except keyutils.KeyParseError as ex:
        # ...
        return QKeySequence.SequenceMatch.NoMatch

	# if sequence matches a pre defined binding
    result = self._match_key(sequence)
    del sequence  # Enforce code below to use the modified result.sequence

    if result.match_type == QKeySequence.SequenceMatch.NoMatch:
        result = self._match_without_modifiers(result.sequence)
    if result.match_type == QKeySequence.SequenceMatch.NoMatch:
        result = self._match_key_mapping(result.sequence)
    if result.match_type == QKeySequence.SequenceMatch.NoMatch:
        was_count = self._match_count(result.sequence, dry_run)
        if was_count:
            return QKeySequence.SequenceMatch.ExactMatch

    if dry_run:
        return result.match_type

    self._sequence = result.sequence
    self._handle_result(info, result)
    return result.match_type

In this method:

  1. It first combines the current keyboard event with previous keyboard events to form a sequence.

  2. The function calls self._match_key(sequence) to match the key sequence.

  3. If no match is found, the function continues to attempt to match the sequence without modifiers using self._match_without_modifiers(result.sequence).

  4. If still unmatched, the function tries to match the key mapping using self._match_key_mapping(result.sequence).

  5. If it still finds no match, the function attempts to match a counter using self._match_count(result.sequence, dry_run).

Here, dry_run is a boolean parameter used to indicate whether to just check for a match without performing any actions. When dry_run is True, the function will return the type of match result without updating the current key sequence or executing any commands.

BaseKeyParser._match_key

def _match_key(self, sequence: keyutils.KeySequence) -> MatchResult:
	"""Try to match a given keystring with any bound keychain.
	"""
	assert sequence
	return self.bindings.matches(sequence)

self.bindings is of type BindingTrie, created in the constructor. Through BindingTrie, it is possible to determine whether the sequence is a NoMatch, ExactMatch, or PartialMatch.

BaseKeyParser._handle_result

The match result is passed into the _handle_result method to determine whether a command needs to be executed.

def _handle_result(self, info: keyutils.KeyInfo, result: MatchResult) -> None:
    """Handle a final MatchResult from handle()."""
    if result.match_type == QKeySequence.SequenceMatch.ExactMatch:
	    # ...
        self.clear_keystring()
        self.execute(result.command, count)
    elif result.match_type == QKeySequence.SequenceMatch.PartialMatch:
        self._debug_log("No match for '{}' (added {})".format(
            result.sequence, info))
        self.keystring_updated.emit(self._count + str(result.sequence))
    elif result.match_type == QKeySequence.SequenceMatch.NoMatch:
        self._debug_log("Giving up with '{}', no matches".format(
            result.sequence))
        self.clear_keystring()
    else:
        raise utils.Unreachable("Invalid match value {!r}".format(
            result.match_type))

If there is an exact match, it calls execute. The execute method is implemented by subclasses and is responsible for the execution of the specific command.

CommandKeyParser

The CommandKeyParser is a subclass of BaseKeyParser. In the previous section, we saw that the parser for insert mode is CommandKeyParser, while the parser for normal mode, NormalKeyParser, is evidently a subclass of CommandKeyParser.

In this section, we'll first introduce the CommandKeyParser, and in the next section, we'll delve into the NormalKeyParser.

CommandRunner

The most distinctive feature of CommandKeyParser is that it includes the qutebrowser CommandRunner as a member variable. When a sequence matches a command, it directly runs through the CommandRunner.

def __init__(self, *, mode: usertypes.KeyMode,
                win_id: int,
                commandrunner: 'runners.CommandRunner',
                parent: QObject = None,
                do_log: bool = True,
                passthrough: bool = False,
                supports_count: bool = True) -> None:
    super().__init__(mode=mode, win_id=win_id, parent=parent,
                        do_log=do_log, passthrough=passthrough,
                        supports_count=supports_count)
	# CommandRunner as member
    self._commandrunner = commandrunner

CommandKeyParser.execute

Evidently, CommandKeyParser only needs to pass the sequence into CommandRunner for execution:

def execute(self, cmdstr: str, count: int = None) -> None:
    try:
        self._commandrunner.run(cmdstr, count)
    except cmdexc.Error as e:
        message.error(str(e), stack=traceback.format_exc())

NormalKeyParser

NormalKeyParser is the corresponding KeyParser for normal mode, and its most distinctive feature is the addition of two timers.

class NormalKeyParser(CommandKeyParser):

    """KeyParser for normal mode with added STARTCHARS detection and more.
    """

    _sequence: keyutils.KeySequence

    def __init__(self, *, win_id: int,
                 commandrunner: 'runners.CommandRunner',
                 parent: QObject = None) -> None:
        super().__init__(mode=usertypes.KeyMode.normal, win_id=win_id,
                         commandrunner=commandrunner, parent=parent)
        self._partial_timer = usertypes.Timer(self, 'partial-match')
        self._partial_timer.setSingleShot(True)
        self._partial_timer.timeout.connect(self._clear_partial_match)
        self._inhibited = False
        self._inhibited_timer = usertypes.Timer(self, 'normal-inhibited')
        self._inhibited_timer.setSingleShot(True)
        self._inhibited_timer.timeout.connect(self._clear_inhibited)

    def __repr__(self) -> str:
        return utils.get_repr(self)

    def handle(self, e: QKeyEvent, *,
               dry_run: bool = False) -> QKeySequence.SequenceMatch:
        """Override to abort if the key is a startchar."""
        txt = e.text().strip()
        if self._inhibited:
            self._debug_log("Ignoring key '{}', because the normal mode is "
                            "currently inhibited.".format(txt))
            return QKeySequence.SequenceMatch.NoMatch

        match = super().handle(e, dry_run=dry_run)

        if match == QKeySequence.SequenceMatch.PartialMatch and not dry_run:
            timeout = config.val.input.partial_timeout
            if timeout != 0:
                self._partial_timer.setInterval(timeout)
                self._partial_timer.start()
        return match

    def set_inhibited_timeout(self, timeout: int) -> None:
        """Ignore keypresses for the given duration."""
        if timeout != 0:
            self._debug_log("Inhibiting the normal mode for {}ms.".format(
                timeout))
            self._inhibited = True
            self._inhibited_timer.setInterval(timeout)
            self._inhibited_timer.start()

    @pyqtSlot()
    def _clear_partial_match(self) -> None:
        """Clear a partial keystring after a timeout."""
        self._debug_log("Clearing partial keystring {}".format(
            self._sequence))
        self._sequence = keyutils.KeySequence()
        self.keystring_updated.emit(str(self._sequence))

    @pyqtSlot()
    def _clear_inhibited(self) -> None:
        """Reset inhibition state after a timeout."""
        self._debug_log("Releasing inhibition state of normal mode.")
        self._inhibited = False
  1. _partial_timer Timer: This timer is used to clear partial key sequences in cases of partial matches. In the handle method, if a key sequence only partially matches (QKeySequence.SequenceMatch.PartialMatch) and dry_run is False, the _partial_timer is started. The timer's interval is determined by the input.partial_timeout setting in the configuration file. When the timer times out, it triggers the _clear_partial_match method, which clears the partially matched key sequence, allowing the user to start entering a new key sequence.

  2. _inhibited_timer Timer: This timer is used to reset the _inhibited state after a certain period, allowing key events to be accepted again. In the set_inhibited_timeout method, if the passed timeout parameter is not zero, the _inhibited_timer is started. The timer's interval is determined by the timeout parameter. When the timer times out, it triggers the _clear_inhibited method, which resets the _inhibited state to False, allowing key events to be accepted again. This feature can be used to temporarily inhibit key event processing during specific operations or time periods.

By utilizing these two timers, the NormalKeyParser can implement functions to clear partial key sequences and reset states under specific conditions. This helps control the logic of key event processing and provides a better user experience.

Conclusion

There are other mode-specific KeyParsers in qutebrowser, which are not covered individually here. Future articles might introduce them separately.

This article has outlined how modes work in qutebrowser, focusing on the underlying logic. There are also corresponding UI elements that interact with these modes, which were not covered in this discussion.


本文作者:Maeiee

本文链接:How Qutebrowser Modes works

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!